iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

Kotlin Ktor 實戰 101系列 第 5

Kotlin Ktor 實戰 101 Day 05 Routing 基礎,Todo API 的第一個端點

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇把 embeddedServer 那行的 3 個角色拆完了,Applicationmodule、engine 各管各的事,day 02 的程式碼裡還剩最後一塊沒講到,routing { get("/") { ... } } 這段 DSL,這篇把它拆開,路由怎麼註冊、path 和 method 怎麼對應 handler、這種寫法為什麼能成立

同時,day 01 那時說過,這個系列有一條貫穿的主線,一個 Todo API,從 day 05 加上第 1 個端點開始,跟著整個系列一路長大,GET /todos 這個端點會在這篇開出來,之後接 JSON、加驗證、抽 repository、換資料庫、加認證,全部都疊在它上面

這篇要完成什麼

  • todo-api 加上第 1 個真正的端點,GET /todos 回傳待辦清單
  • 拆解 routing DSL,routing { } 是什麼、get(...) 在做什麼、handler 裡的 call 是誰
  • 弄清楚這種寫法在語言層面為什麼能成立
  • 對照 Relix 手刻時路由是怎麼從一個 Map 長成 DSL 的

先寫測試

這篇要加的是一個新端點,行為很明確,測試先寫下來當規格。在 src/test/kotlin/com/cashwu/todo/TodoRoutesTest.kt 加上

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class TodoRoutesTest {
    @Test
    fun `todos path responds all todos`() = testApplication {
        application {
            module()
        }

        val response = client.get("/todos")

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals("買牛奶\n繳電費\n寫 day 05 的文章", response.bodyAsText())
    }

    @Test
    fun `todos path with trailing slash responds not found`() = testApplication {
        application {
            module()
        }

        val response = client.get("/todos/")

        assertEquals(HttpStatusCode.NotFound, response.status)
    }
}

第 1 個測行為,打 GET /todos 要拿到 200,body 是換行分隔的 3 筆待辦

第 2 個測邊界,而且是刻意挑的邊界,打的路徑是 /todos/,只比第 1 個多一個尾斜線,期望卻是 404,Ktor 預設把 /todos/todos/ 當成 2 條不同的路徑,我們只註冊了前者,後者沒人接就是 404,這個行為的成因,還有想讓 2 條路徑一視同仁的話要怎麼改,是 day 06 匹配規則的主題,這裡先用測試把預設行為確認下來,之後 day 06 動到它時,這個測試會第一時間告訴我們行為變了

實作

src/main/kotlin/com/cashwu/todo/Application.kt 改成

package com.cashwu.todo

import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing

val todos = mutableListOf("買牛奶", "繳電費", "寫 day 05 的文章")

fun main() {
    embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}

fun Application.module() {
    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
        get("/todos") {
            call.respondText(todos.joinToString("\n"))
        }
    }
}

跟 day 02 的版本相比只多了 2 個東西,一個 top-level 的 todos 清單,還有 routing 區塊裡的第 2 條路由,改動很小,正好把注意力放在這幾行到底在做什麼

拆開 routing DSL

routing 不是語言魔法

routing { } 看起來像個語法結構,其實它就是一個普通的函式,它做的事是把 Routing 這個 plugin 裝到 Application 上,再把你給的區塊套用上去,區塊裡註冊的路由全部進到這個 plugin 管理的路由樹裡

plugin 這個詞在系列裡是第 1 次出現,先給一個夠用的理解就好,plugin 是 Ktor 幫 Application 加能力的機制,routing 是其中一個,之後要裝的 ContentNegotiation、Authentication 也都是

get 在做什麼

get("/todos") { ... } 是在路由樹上註冊一筆對應,HTTP method 是 GET、path 是 /todos 的請求,交給後面那個 lambda 處理,請求進來時,Ktor 拿 method 和 path 去路由樹裡找,找到就執行對應的 handler,找不到就回 404,day 03 那個打 /nothing-here 拿到 404 的測試,走的就是「找不到」這條路

get 之外,postputdelete 這些 builder 都有,一個 method 一個,之後的篇章會逐一用到,Todo API 的新增、修改、刪除就靠它們

handler 裡的 call

handler 是一個 suspend lambda,所以裡面可以直接呼叫 call.respondText(...) 這類 suspend 函式,不用自己處理協程,跟 day 03 講 testApplication 的 lambda 是同一個道理

call 代表這一次請求與回應的上下文,請求的資訊從它身上讀,回應也透過它寫回去,call.respondText(...) 做的事就是把一段純文字連同 200 狀態碼寫進回應,狀態碼和 content type 都有預設值,要改的話它有參數可以使用,後面的篇章用到再說

這種寫法為什麼能成立

把語法糖剝掉一層就清楚了,get("/todos") { ... } 其實是 get("/todos", { ... }),Kotlin 規定最後一個參數是 lambda 時可以移到括號外面,這叫 trailing lambda,而 routing { } 的區塊是 lambda with receiver,區塊裡的 this 是路由樹的節點 (Route),get 正是定義在 Route 上的 extension function,看一眼 import 那行 io.ktor.server.routing.get 就知道它不是什麼關鍵字,只是一個函式,所以整段 DSL 就是普通的函式呼叫加上 2 個語言特性,沒有 annotation processing,也沒有 code generation

這 2 個特性 Relix 系列在 day 10 從零拆解過,那篇把 lambda with receiver 從普通 lambda 一步步推到 routing DSL,想看完整推導的話可以回去讀這篇文章

先把取捨講清楚

這版實作有 2 個地方,不是好的做法,是刻意的最簡起點

  • todos 是放在 top-level 的 mutableList,資料放在記憶體,server 重新啟動就消失,也還沒有任何並行保護,2 個請求同時改它會有問題,day 19 會把它抽成 repository 交給 DI 管,day 20 之後換成真的資料庫,在那之前它就是一個能讓路由有東西可回的最小資料來源
  • 回應是純文字,不是 JSON,正式的 API 當然回 JSON,但那需要 ContentNegotiation,是 day 12 的主題,現在 joinToString("\n") 拼出來的純文字就夠用了

跑起來看結果

./gradlew test

實測的結果是

> Task :test

ApplicationTest > root path responds hello() PASSED

ApplicationTest > unknown path responds not found() PASSED

EnvironmentTest > Ktor EmbeddedServer class is available() PASSED

TodoRoutesTest > todos path responds all todos() PASSED

TodoRoutesTest > todos path with trailing slash responds not found() PASSED

BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html

5 個測試通過,2 個是這篇新增的。再用真的 HTTP 確認一次,./gradlew run 跑起 server 之後,另開視窗打

curl http://localhost:8080/todos
買牛奶
繳電費
寫 day 05 的文章

第 1 個端點通了。順手把尾斜線那條也打一次,只印狀態碼

curl -o /dev/null -w "%{http_code}" http://localhost:8080/todos/
404

跟測試講的一樣,/todos/ 是另一條路,沒註冊就是 404

常見陷阱

  • 忘了 routing { },直接在 module 裡寫 get(...)

初學很容易這樣寫

fun Application.module() {
    get("/todos") {
        call.respondText(todos.joinToString("\n"))
    }
}

這個編譯不會過。前面說了,get 是定義在 Route 上的 extension function,module 裡的 thisApplication,不是 Route,compiler 找不到能用的 get 就直接出現編譯錯誤,錯在編譯期反而是好事,不會做出一個看起來能跑但路由沒註冊的 server,看到這種錯誤訊息,先檢查是不是少包了一層 routing { }

  • 預設尾斜線是另一條路

已經由第 2 個測試示範過了,/todos/todos/ 是 2 條路徑,只註冊一條,另一條就是 404,從瀏覽器或別的工具複製 URL 過來的時候,尾巴多一個斜線很常見,打不到的時候先看一眼路徑尾巴。這個行為能不能改、怎麼改,day 06 講匹配規則時一起處理

跟 Relix 的對照

Relix 的路由是從一個 Map 起家的。day 07 用 2 層 Map 做路由表,外層 key 是 path、內層 key 是 method,查詢就是 2 層取值,查不到就自己回 notFound(),連 404 這件事都是自己寫的一行程式,那篇也把 Map 路由的限制列得很清楚,沒有 405、/hello/hello/ 是 2 個不同的 key、路徑參數做不到,後面幾篇才用 Router 一項一項補上

DSL 也是後來才長出來的,一開始註冊路由是 app.get("/hello") { ... } 這種散裝寫法,day 10 才用 lambda with receiver 做出 routing { },讓 RoutingBuilder 收集路由再交給 Router,巢狀的 route("/api") { ... } 又是再下一篇的事

回頭看這篇,Ktor 開箱給的就是那個「長完的形狀」,routing { }、method builder、404,全部都在,而且底下是路由樹,不是扁平的 Map,巢狀路由、路徑參數這些 Relix 花好幾篇才補齊的能力,它本來就支援,後面的篇章用到就直接拿,手刻過一次的價值在這裡,你知道 DSL 底下那棵樹在回答什麼問題,也知道「查不到回 404」不是理所當然,是有人寫的

有一個小地方兩邊走向不同,Relix 的 Router 後來把 trailing slash 放進匹配規則裡處理掉了,Ktor 的預設反而是把它們當 2 條路徑,要一視同仁得自己開,預設值沒有絕對的對錯,但這正好說明為什麼要用測試把預設行為固定住,框架的預設跟你的直覺不一定同一邊


小結

Todo API 的主線從這篇開始了,第 1 個端點 GET /todos 用 2 個測試確認了行為和邊界,5 個測試通過,routing DSL 也拆完了,routing { } 是把 Routing plugin 裝上 Application 的普通函式,get(...) 在路由樹上註冊 method 加 path 對應的 handler,handler 是 suspend lambda,call 是這一次請求與回應的上下文,整段語法靠 trailing lambda 和 lambda with receiver 成立,資料還在記憶體、回應還是純文字,這些都是刻意的


下一篇

下一篇講路由的匹配規則,path 參數 {id} 和 query 參數怎麼拿、怎麼驗,還有這篇留下的尾斜線問題,/todos/todos/ 為什麼預設是 2 條路徑、想讓它們走同一條要怎麼做,Todo API 也會加上「取單筆待辦」的端點


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 04 Application、Module 與 Engine
下一篇
Kotlin Ktor 實戰 101 Day 06 路徑參數、Query Parameters 與匹配規則
系列文
Kotlin Ktor 實戰 1017
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言